Skip to content

Implement the async Clipboard API (navigator.clipboard) - #33312

Open
cirospaciari wants to merge 133 commits into
mainfrom
claude/clipboard-api-native
Open

cirospaciari wants to merge 133 commits into
mainfrom
claude/clipboard-api-native

Conversation

@cirospaciari

@cirospaciari cirospaciari commented Jul 3, 2026 •

Copy link
Copy Markdown
Member

What

Implements the W3C Clipboard API: navigator.clipboard (readText, writeText, read, write), ClipboardItem, and ClipboardEvent, backed by the OS clipboard on macOS, Windows, and Linux/BSD. A successful write fires copy and a successful read fires paste at navigator.clipboard.

await navigator.clipboard.writeText("hello");
console.log(await navigator.clipboard.readText()); // "hello"

await navigator.clipboard.write([
  new ClipboardItem({
    "text/plain": "hello",
    "text/html": new Blob(["<b>hello</b>"], { type: "text/html" }),
  }),
]);
const [item] = await navigator.clipboard.read();
console.log(item.types, await (await item.getType("text/plain")).text());

Architecture

Piece Follows Files
Clipboard: an EventTarget; navigator.clipboard is the lazily created per-global singleton JSPerformance / JSMessagePort webcore/Clipboard.{h,cpp}, webcore/JSClipboard.{h,cpp}
ClipboardItem: WebIDL record constructor, [SameObject] frozen types, getType(), static supports(); each representation is observed with DOMPromise::whenSettledWithResult, as upstream WebKit does JSCloseEvent (getDOMConstructor) webcore/ClipboardItem.{h,cpp}, webcore/JSClipboardItem.{h,cpp}
ClipboardEvent: an Event subclass JSCloseEvent webcore/ClipboardEvent.{h,cpp}, webcore/JSClipboardEvent.{h,cpp}
Platform backend: one bun_jsc::Job per operation on the work pool; WebCore passes byte ranges tagged with a MIME enum and an opaque request, and the job completes or releases it on the JS thread other JobContext implementors src/runtime/webcore/clipboard.rs, webcore/ClipboardPlatform.{h,cpp}

Operations are not ordered relative to each other. A write() that is still collecting its item is superseded (rejects with AbortError) by any later write; once scheduled, a write lands whenever the platform runs it. In-process serialization exists only where the platform needs it: one Win32 clipboard transaction per process (OpenClipboard(NULL) does not exclude a second thread of the same process, and concurrent reads corrupted the heap), and one NSPasteboard call at a time on macOS (AppKit segfaulted otherwise). Bun.Image.fromClipboard() shares both; on Windows it takes the lock without waiting and makes a single open attempt, as before this PR.

Platform behavior

text/plain text/html image/png Mechanism
macOS ✅ ✅ ✅ (a TIFF-only image is converted) NSPasteboard; read() takes every type under one lock and retries if another process writes meanwhile; a failed write leaves the pasteboard empty
Windows ✅ ✅ (CF_HTML) ✅ (also written as CF_DIBV5) Win32 clipboard; read() is one open span; a failed multi-format write leaves the clipboard empty
Linux / BSDs ✅ ✅ ✅ wl-clipboard or xclip; xsel can only read text; one representation per write

On Linux, read() first asks the selection owner which types it offers (wl-paste --list-types, xclip's TARGETS) and reads only those, and the first helper that reaches the display answers for the clipboard; an offered type it cannot deliver fails the read. A non-zero helper exit counts as "nothing copied" only when the helper says so; anything else, such as a stale $DISPLAY, is a failure. The payload of a write reaches the helper through a memfd, or a 0600 temp file unlinked before any helper runs, so it never has a name another process can open. The backend finds each helper on the PATH of the script and runs it directly. One helper run has a time limit of 10 seconds, because a hung X11 selection owner blocks a helper forever. At the limit the helper and its process group get SIGKILL.

Failures reject with a NotAllowedError whose message says what to fix (no display, no helper installed, the helper failed, or the helper could not be started, with the system error). On every platform readText() also rejects with a NotAllowedError when the text is too large for a string.

Changes outside the clipboard

  • DOMPromise::whenSettledWithResult (JSDOMPromise.{h,cpp}): ported from upstream WebKit.
  • WebCore::Blob::create(bytes, type, globalObject) (blob.{h,cpp}) with its Rust factory Blob__fromBytesWithNormalizedType, plus the impl-level Blob accessors the clipboard uses (Blob.rs).
  • spawn_sync: Options::forward_signals (default true) so work-pool spawns do not arm the process-wide signal forwarder, an opt-in Options::timeout (POSIX) with Options::linux_pdeathsig, and a debug assertion that SyncStdio::Pipe (the caller-owned descriptor that main added) is for stdin. A caller that sets no time limit makes the same system calls as before.
  • bun_threading::Mutex::try_lock is available in release builds.
  • bun_sys::windows: the global-memory and Sleep externs in kernel32, and a user32 module with the clipboard externs (replacing an empty placeholder re-export). EmptyClipboard, CloseClipboard, and SetClipboardData are unsafe; the owning wrappers live next to the backend and backend_wic uses them.
  • image_coregraphics_shim.cpp: the pasteboard lock, and Bun.Image's reader takes it too.
  • bun:internal-for-testing: setClipboardHelperTimeoutForTesting(ms), the testing hook for the time limit of a helper run.

Spec coverage and differences

  • ClipboardItem keys are the mimesniff serialization of the given type (parameters kept), as in Chrome; getType() matches that exactly first, then by essence.
  • File- and S3-backed Blobs (Bun.file()) can be written; their bytes are read in before the platform write, and a failed read rejects with NotAllowedError carrying the system error.
  • No permission prompts; ClipboardEvent.clipboardData is always null and a non-null init value throws (no DataTransfer); cut is never fired; write() takes one item; web custom formats ("web " prefix) are not implemented.

Downsides

  • There is no permission model. Every script that runs in Bun, and every dependency it loads, can read and replace the clipboard of the user with no prompt. A browser asks the user first. This PR does not decide whether Bun needs a flag or a prompt for this: that decision is for a maintainer.
  • navigator.clipboard cannot be assigned. In a module, navigator.clipboard = mock and Object.assign(navigator, { clipboard: mock }) throw TypeError: Attempted to assign to readonly property. Bun 1.4.3 and Node 26 accept both, because the property does not exist there. A test suite that installs a clipboard mock by assignment breaks. Object.defineProperty(navigator, "clipboard", { value: mock }) works before and after. This PR does not decide whether the property gets a setter.
  • On Linux and the BSDs a helper that hangs holds one work-pool thread until the time limit of 10 seconds, once for each candidate helper. worker.terminate() waits for the helper run in flight. A read then stops. A write goes on to its next candidate, so with both displays a hung write holds the worker for up to 20 seconds.
  • At the time limit the helper gets SIGKILL, so it cannot clean up. The limit lives in the Bun process: when Bun is killed, the hung helper of a write stays. The kernel ends the helper of a read.
  • For a helper that does not hang, each run costs the pool thread 2 more system calls (23 to 25 for a read, 8 to 10 for a write). sync::Options grows from 112 to 128 bytes. The release binary grows by 1,561 bytes.

Tests

test/js/web/clipboard/clipboard.test.ts. Tests that touch the real clipboard run on CI or with BUN_TEST_SYSTEM_CLIPBOARD=1; the macOS and Windows CI lanes fail instead of skipping when the clipboard is unreachable. Coverage: the WebIDL surface, validation and rejection messages, supersession, GC and teardown, collection that consults no user-replaceable promise machinery, worker termination while collecting, events, round-trips; Linux helpers played by stand-ins on PATH (candidate order, the targets probe, clean versus display failures, crashes, the time limit for a helper that hangs and ignores SIGTERM, a helper that the kernel cannot start, text that is too large for a string, payload staging, worker teardown); the Win32 formats driven through bun:ffi (foreign CF_HTML, text edge cases, PNG and the CF_DIBV5 layout); pbcopy/pbpaste and clip.exe/Get-Clipboard interop; and a race of reads, writes and Bun.Image.fromClipboard() on the in-process backends.

Notes on the helper time limit and on large text (commits 96de433 to c0eff53)

Two defects at d28ff32

  • readText() aborted the process on large text. Text of 1 GiB or more that is not all ASCII aborted with no message. Any text of 2 GiB or more aborted with ASSERTION FAILED: string.size() <= String::MaxLength.
  • A helper that ignores SIGTERM was never ended, so readText() and writeText() never settled. The watchdog sent only SIGTERM.

Which fix for the helper that is never ended

Two fixes were weighed.

  1. kill -9 "$c" in the watchdog string. It is one line. Checked on d28ff32: the hang ends.
  2. A time limit in sync::spawn, and no shell. This PR takes it.

Reasons for 2:

  • The watchdog needs sh and sleep from the PATH of the script. With only the helpers on PATH the limit never fires.
  • Where output is captured through a socket pair, a process that the helper started holds the pair open and the wait never ends.
  • Each helper run started 4 processes (sh, the helper, the watchdog subshell, sleep). It now starts 1.
  • The test hook was an environment variable that production code read.
  • The 2026-09-15 review named the cause: the shell string, the quoting, the watchdog and the env var are "all there because spawn_sync has no stdin source and no timeout".

The three commits are separable. 96de433301 (large text) stands alone.

What changes for a user, compared with the watchdog

  • The helper leads its own process group. A signal to the process group of Bun no longer reaches the daemon that xclip or wl-copy leaves behind.
  • An empty PATH finds no helper. The shell searched the working directory.
  • The helper gets its full path as argv[0].
  • The helper starts in the working directory that the script has. Before, the spawn changed to "." first. That fails with EACCES for a user who is not root when the directory has lost its search permission.
  • A helper that is a script with no #! line fails with "The clipboard helper could not be started". The shell ran such a file. spawn: retry via /bin/sh when exec returns ENOEXEC #31717 adds that retry to the spawn.
  • The helper of a read gets SIGKILL when the Bun process is gone (PR_SET_PDEATHSIG, Linux).
  • A read in a worker that is terminated starts no further helper.
  • /bin/sh is not needed.

Large text

  • Text up to the length limit of a string resolves. Larger text rejects with a NotAllowedError and fires no paste event.
  • Text of 1 GiB or more that is not valid UTF-8 also rejects with that message. read() with Blob.text() decodes such text. The shared converter (Zig::convertUTF8ToString) gives up at that size.
  • The whole text is still held in memory before the check.

Measurements (Linux x64)

  • A caller of sync::spawn that sets no time limit makes the same system calls as before. 36 cases (memfd or socket pair, 3 stdio shapes, 6 child behaviours), 5 runs each, spawning thread traced with ptrace: 180 of 180 traces identical.
  • One helper run, pool thread, inside sync::spawn: 23 to 25 system calls for a read, 8 to 10 for a write (debug build). The 2 more are one wait4(WNOHANG) and one poll on the pidfd.
  • Without a pidfd (FreeBSD, or a kernel with no pidfd_open) the wait looks at the helper at intervals of 1, 2, 4 and so on up to 100 ms. A helper that runs 50 ms costs 6 poll and 7 wait4 calls. One that runs 2 s costs 26 and 27.
  • sync::Options: 112 to 128 bytes. sync::Result: 96 to 96.
  • Release bun, size: text 80,747,788 to 80,749,413 (+1,625), data 110,488 to 110,424 (-64). The file grows by 4,096 bytes.
  • Instructions per call: not measured. perf and valgrind are not on the machine.

Tests

  • These fail on d28ff32: PATH="" (it ran ./xclip), the size test (it resolved 1,048,577 characters), the worker read test (xsel started), the socket pair tests (timeout), the killed-process test (helper still alive). With the old env var at 1 second, a helper that ignores SIGTERM never settled and a child of the helper stayed.
  • These pass on d28ff32 too, and pin a clause of the new code: a helper that the kernel cannot start, a write in a terminated worker, the daemon of a write. With the clause removed, each of them fails.
  • The FreeBSD branch (no pidfd, no memfd) runs on Linux through BUN_FEATURE_FLAG_FORCE_WAITER_THREAD=1 and BUN_FEATURE_FLAG_DISABLE_MEMFD=1.
  • The tests for the time limit and for text that is too large set their hook in the test process and call the clipboard there. A child needs about 1.5 seconds to load bun:internal-for-testing in a debug build, and several children at once need longer.
  • Not covered: an unset PATH (the default path is a constant of the system), FreeBSD itself, a real xclip or wl-clipboard in CI, and a working directory without search permission (it needs a user who is not root, and I checked it by hand).
  • Not covered in CI: text of 1 GiB. Only a real payload of that size reaches the converter branch, and such a test needs several GiB and its own timeout. Checked by hand with a stand-in that prints 1 GiB: exit 134 on d28ff32, 1,073,741,825 characters with this PR.
  • Not run by me: Windows and macOS. The new readText() size test for the real clipboard runs there in CI for the first time.

Named for later

Self-reviewed: 5 concerns raised, 4 addressed. Not addressed: a CI test for text of 1 GiB (see Tests).


no test proof · iteration 36 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/web/clipboard/clipboard.test.ts

Adds the W3C Clipboard API: `navigator.clipboard` with `readText`,
`writeText`, `read`, and `write`, the `copy`/`paste` events, and the
`ClipboardItem` and `ClipboardEvent` classes, backed by the OS clipboard
on macOS (NSPasteboard), Windows (Win32), and Linux/BSD (`wl-clipboard`,
`xclip`, or `xsel`).

Each interface is an individual native class with its own constructor:

- `Clipboard` is a WebCore-style EventTarget subclass; `navigator.clipboard`
  is its lazily created per-global singleton and the four promise methods
  are native (JSClipboard.cpp).
- `ClipboardItem` is a Bun-native class (prototype + constructor +
  LazyClassStructure) with WebIDL record validation, a frozen `types`
  array, `getType()` normalization, and a static `supports()`
  (JSClipboardItem.cpp).
- `ClipboardEvent` is a WebCore-style Event subclass (JSClipboardEvent.cpp).
- The three globals are lookup-table entries on the global object.

The platform I/O lives in Rust (src/runtime/webcore/clipboard.rs): each
operation runs as a work-pool job (never on the JS thread), settles its
promise back on the JS thread, and fires the corresponding clipboard
event only on success. The Rust backend is also the single source of
truth for per-platform capabilities, which `ClipboardItem.supports()` and
`write()` validation query through two exported predicates.

Supported representations: `text/plain`, `text/html`, and `image/png`
(no `text/html` on Windows, which needs a `CF_HTML` envelope). Failures
reject with a `NotAllowedError` DOMException whose message names the fix.
File-backed Blobs (`Bun.file()`) are rejected in `write()` with a
TypeError instead of being written as empty data.
@cirospaciari
cirospaciari requested a review from alii as a code owner July 3, 2026 20:10
@robobun

robobun commented Jul 3, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 2:12 PM PT - Oct 16th, 2026

✅ @robobun, your commit 78695f47481eba5534d2cf230cae6c49fb092c45 passed in Build #126809! 🎉


🧪   To try this PR locally:

bunx bun-pr 33312

That installs a local version of the PR into your bun-33312 executable, so you can run:

bun-33312 --bun

@mintlify

mintlify Bot commented Jul 3, 2026 •

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
bun 🟢 Ready View Preview Jul 3, 2026, 8:12 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@coderabbitai

coderabbitai Bot commented Jul 3, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds Clipboard, ClipboardItem, and ClipboardEvent support across Bun's typings, parser globals, WebCore/JSC bindings, native clipboard backends, runtime wiring, documentation, and tests.

Changes

Clipboard API implementation

Layer / File(s) Summary
TypeScript types and docs
packages/bun-types/globals.d.ts, docs/runtime/web-apis.mdx, test/integration/bun-types/bun-types.test.ts
Adds Navigator.clipboard, Clipboard, ClipboardItem, DataTransfer, and ClipboardEvent typings, updates the web API support table, and adjusts the DOM-less empty-interface expectation.
Parser globals and env vars
src/js_parser/defines_table.generated.rs, src/js_parser/defines_table.string-map.ts, src/bun_core/env_var.rs
Registers Clipboard and ClipboardItem as pure global identifiers and adds POSIX-only DISPLAY and WAYLAND_DISPLAY env accessors.
WebCore Clipboard and ClipboardEvent classes
src/jsc/bindings/webcore/Clipboard.*, ClipboardEvent.*, EventFactory.cpp, EventHeaders.h, EventTargetFactory.cpp, EventTargetHeaders.h, DOM*IsoSubspaces.h
Adds WebCore Clipboard and ClipboardEvent classes, their IDL definitions, and active event/eventtarget factory and ISO subspace wiring.
JSClipboardItem binding
src/jsc/bindings/JSClipboardItem.{h,cpp}
Implements the ClipboardItem JSC binding with MIME validation, constructor logic, accessors, getType() resolution, and class-structure setup.
JSClipboard and JSClipboardEvent bindings
src/jsc/bindings/webcore/JSClipboard.*, JSClipboardEvent.*
Implements Clipboard prototype methods and wrapper plumbing, plus the ClipboardEvent JS wrapper and related FFI hooks.
ZigGlobalObject wiring
src/jsc/bindings/ZigGlobalObject.{h,cpp}, ZigGlobalObject.lut.txt
Registers clipboard bindings in the global object lookup tables, GC members, constructor getters, and lazy navigator.clipboard initialization.
Native platform backends
src/jsc/bindings/image_coregraphics_shim.cpp, src/runtime/webcore.rs, src/runtime/webcore/clipboard.rs, src/spawn/process.rs
Adds macOS clipboard shim functions, the Rust backend for macOS/Windows/POSIX clipboard operations, and signal-forwarding gating for spawned helper processes.
Clipboard test suite
test/js/web/clipboard/clipboard.test.ts
Adds tests covering interface shape, ClipboardItem/ClipboardEvent behavior, read/write round trips, event firing, and helper-path behavior.

Suggested reviewers: alii

Pre-merge checks: Passed 4
Passed Passed checks: 4 passed
Check name Status Explanation
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Description check Passed The description clearly explains the Clipboard API implementation, architecture, platform behavior, limitations, and extensive test coverage. It uses different headings from the template, but it provi…
Title check Passed The title clearly and concisely identifies the primary change: implementing the asynchronous Clipboard API through navigator.clipboard.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/jsc/bindings/JSClipboardItem.cpp`:
- Around line 189-195: Normalize clipboard MIME types before storing or
comparing them in ClipboardItem so case variants map to the canonical form.
Update the logic in JSClipboardItem::getType, the type collection/storage path
that appends to m_types, and the supports() call site to parse/serialize the
MIME string first, then use the canonical value for matching and reported types.
Also ensure invalid getType() input is validated through the MIME parser so it
throws the TypeError path instead of falling through to NotFoundError, and add
tests for uppercase keys, duplicate parsed keys, and invalid getType() input.

In `@src/jsc/bindings/webcore/ClipboardEvent.idl`:
- Around line 28-33: Remove Worker exposure from ClipboardEvent so it matches
the spec and stays out of worker contexts. Update the Exposure declaration on
the ClipboardEvent interface in the ClipboardEvent.idl definition to expose it
only in Window, and keep the constructor and clipboardData attribute unchanged
unless there is an explicit compatibility exception.

In `@src/jsc/bindings/webcore/JSClipboard.cpp`:
- Around line 262-276: The Clipboard write path in JSClipboard::write currently
rejects when items.size() > 1; update it so single-item backends fall back to
using items.at(0) instead of returning NotAllowedError for multiple items. Keep
the existing type validation loop for the selected Bun::JSClipboardItem, but
only apply the one-representation-per-item rejection when the backend truly
cannot handle multiple representations, not when there are multiple
ClipboardItems.

In `@src/runtime/webcore/clipboard.rs`:
- Around line 857-859: The clipboard helper path in `timeout_prefix()`/the
caller currently falls back to running `wl-paste`/`xclip`/`xsel` without any
timeout when no external timeout binary is found. Update the clipboard execution
flow in `clipboard.rs` so it either uses an internal spawn timeout or fails
closed instead of returning `argv` unwrapped, and ensure every
error/abort/timeout path actively completes the operation rather than leaving
the promise pending.
- Around line 988-989: The temp-file staging in the clipboard write path can
leave a partial payload behind when File::openat succeeds but
file.write_all(bytes).ok()? fails. In the clipboard staging logic around the
File::openat/write_all flow, make sure the created path is unlinked immediately
on any write failure before returning None, so the acquisition is paired with
cleanup at the same site.

In `@test/js/web/clipboard/clipboard.test.ts`:
- Around line 331-386: The clipboard event test only preserves text via
navigator.clipboard.readText, so it can overwrite richer clipboard contents and
fail to restore them in the finally block. Update the save/restore logic in this
test to capture and restore full clipboard items like the round-trip clipboard
test does, while keeping a text-only fallback when full-item access is
unavailable. Make sure the restore path still uses navigator.clipboard.writeText
for the fallback and that the event assertions remain unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a48f19a4-f86c-462d-b048-0ae0e458e7a9

📥 Commits

Reviewing files that changed from the base of the PR and between 5d76ac6 and 2cafcd2.

📒 Files selected for processing (31)
  • docs/runtime/web-apis.mdx
  • packages/bun-types/globals.d.ts
  • src/bun_core/env_var.rs
  • src/js_parser/defines_table.generated.rs
  • src/js_parser/defines_table.string-map.ts
  • src/jsc/bindings/JSClipboardItem.cpp
  • src/jsc/bindings/JSClipboardItem.h
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/jsc/bindings/ZigGlobalObject.h
  • src/jsc/bindings/ZigGlobalObject.lut.txt
  • src/jsc/bindings/image_coregraphics_shim.cpp
  • src/jsc/bindings/webcore/Clipboard.cpp
  • src/jsc/bindings/webcore/Clipboard.h
  • src/jsc/bindings/webcore/Clipboard.idl
  • src/jsc/bindings/webcore/ClipboardEvent.cpp
  • src/jsc/bindings/webcore/ClipboardEvent.h
  • src/jsc/bindings/webcore/ClipboardEvent.idl
  • src/jsc/bindings/webcore/DOMClientIsoSubspaces.h
  • src/jsc/bindings/webcore/DOMIsoSubspaces.h
  • src/jsc/bindings/webcore/EventFactory.cpp
  • src/jsc/bindings/webcore/EventHeaders.h
  • src/jsc/bindings/webcore/EventTargetFactory.cpp
  • src/jsc/bindings/webcore/EventTargetHeaders.h
  • src/jsc/bindings/webcore/JSClipboard.cpp
  • src/jsc/bindings/webcore/JSClipboard.h
  • src/jsc/bindings/webcore/JSClipboardEvent.cpp
  • src/jsc/bindings/webcore/JSClipboardEvent.h
  • src/runtime/webcore.rs
  • src/runtime/webcore/clipboard.rs
  • test/integration/bun-types/bun-types.test.ts
  • test/js/web/clipboard/clipboard.test.ts

Comment thread src/jsc/bindings/JSClipboardItem.cpp Resolved
Comment thread src/runtime/webcore/clipboard.rs Resolved
Comment thread src/runtime/webcore/clipboard.rs Resolved
Comment thread test/js/web/clipboard/clipboard.test.ts Resolved
Comment thread src/runtime/webcore/clipboard.rs Resolved
Comment thread src/runtime/webcore/clipboard.rs Resolved
Comment thread src/runtime/webcore/clipboard.rs Resolved
…ignals

- Normalize ClipboardItem types to their lowercased serialization (the
  spec's parse-and-serialize step) and lowercase before the platform
  `supports()` check; record enumeration goes through the method table so
  Proxy and other exotic records work, re-checking enumerability per key.
- Run every Linux/BSD clipboard helper through a `/bin/sh` watchdog so a
  hung selection owner is killed after 10s without depending on coreutils'
  `timeout`; missing helpers (exit 127/126) and timeouts (124) are told
  apart from real failures, and the temp payload file is unlinked when
  staging fails.
- Only arm the sync spawner's process-wide signal forwarding (and the
  `Bun__currentSyncPID` handshake) on the main thread, mirroring the
  existing `no_orphans` gate, so work-pool callers like the clipboard jobs
  never replace the user's signal handlers.
- Reject S3-backed Blobs in `write()` like file-backed ones, check
  MarkedArgumentBuffer overflow, and make `ClipboardItem.supports()` throw
  when called with no argument.
Comment thread src/runtime/webcore/clipboard.rs Resolved
The previous commit gated the sync spawner's signal forwarding on the
parent-death watchdog's arming thread, but that watchdog is not armed in
every configuration, which left `bun run` without a SIGINT forwarder
(caught by test/regression/issue/ctrl-c.test.ts in CI). Gate on the
process's main thread (`cli_state::is_main_thread`) instead, which is the
contract the C++ forwarder documents.

Also rework the clipboard helper watchdog so its `sleep` cannot outlive
the invocation or hold the helper's captured stdout open (which would
have made reads take the full 10s wherever the capture uses a pipe): the
watchdog group is fully redirected and reaps its `sleep` on TERM.
…native

# Conflicts:
#	src/jsc/bindings/ZigGlobalObject.cpp
#	src/jsc/bindings/ZigGlobalObject.lut.txt
Windows was the one platform without `text/html`: reads and writes now go
through the registered "HTML Format" (CF_HTML), wrapping the fragment in
the offset-header envelope on write and extracting the fragment by its
validated offsets on read. The supported-representation list is now the
same on every platform, so the per-platform special cases in the docs,
types, and tests are gone.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/webcore/clipboard.rs`:
- Around line 653-662: The `clipboard.rs` HTML handling in `cf_html_fragment`
fallback currently returns the full raw `CF_HTML` envelope when fragment
validation fails. Update the `text/html` branch to treat invalid
`StartFragment`/`EndFragment` data as an unavailable representation instead of
using `unwrap_or(bytes)`, so malformed payloads do not leak header/boilerplate
content; keep the behavior localized to the `Mime::TextHtml` path and
`cf_html_fragment` call site.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 26a01af6-7b6d-4e9c-a196-f22dcf2ceb81

📥 Commits

Reviewing files that changed from the base of the PR and between 1274cd4 and 44104b4.

📒 Files selected for processing (5)
  • docs/runtime/web-apis.mdx
  • packages/bun-types/globals.d.ts
  • src/jsc/bindings/webcore/Clipboard.idl
  • src/runtime/webcore/clipboard.rs
  • test/js/web/clipboard/clipboard.test.ts

Comment thread src/runtime/webcore/clipboard.rs Resolved
If neither the offset header nor the fragment comment markers can be
validated, reading `text/html` on Windows now reports the representation
as absent instead of returning the raw envelope bytes as HTML.
Comment thread src/runtime/webcore/clipboard.rs Resolved
Comment thread src/jsc/bindings/JSClipboardItem.cpp Resolved
- The helper watchdog installs its TERM trap before starting `sleep` and
  exits from the trap, so a fast helper can no longer orphan the `sleep`
  or send a stray kill after the parent already reaped the helper.
- `getType()` lowercases its argument like the stored `types`, and the
  `ClipboardItem` constructor rejects two spellings of the same MIME type
  with a TypeError instead of producing a duplicated `types` list.
clippy (needless_pass_by_value): create_items_array only reads the
representation list, so take a slice instead of consuming the Vec.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At most, there should be one strong here and that's only in the Promise being returned.

Do not use JSC::JSNativeStdFunction. This should follow the existing pattern for resolve/reject functions. If you need to pass in the additional context argument to that Promise then use the context argument.

There should not be any promise chaining either.

cirospaciari and others added 2 commits July 16, 2026 20:07
Clipboard.prototype.write built an N-deep promise chain out of capturing
JSNativeStdFunctions, rooting the state each lambda closed over in a
JSC::Strong. Replace the chain with a resumable loop over the item's
representations, and the lambdas with shared non-capturing host functions
registered through performPromiseThenWithContext, following the reaction
convention the streams code already uses: the handler is invoked as
handler(value, contextCell) and reads its state from the context.

The state one write carries across those reactions now lives in a
JSClipboardWriteState cell whose fields are visited, so no Strong is needed
on this side at all. Bun__Clipboard__writeBlobs takes the promise write()
already returned and the scheduled job settles it, leaving the job's
JSPromiseStrong as the only strong handle involved.

Only an object can be a thenable, so string representations now normalize
inline and cost no reaction; getType() on a string resolves without one.

While here: the NSPasteboard reader stashed its retained NSData in a
thread_local between the size and copy phases, so the copy phase ignored the
UTI and trusted whatever was stashed. Hand back an explicit handle instead
and have the caller pass it to bun_coregraphics_clipboard_take_data, which
copies and releases it. Both phases report failure, so a shim that cannot
load can no longer surface a run of zeroes as the clipboard's contents.
Comment thread src/runtime/webcore/clipboard.rs Resolved
@cirospaciari

cirospaciari commented Jul 17, 2026 •

Copy link
Copy Markdown
Member Author

All three addressed in 8ff1962.

No JSNativeStdFunction, no chaining. write() is a resumable loop over the item's representations instead of an N-deep .then() chain, and both it and ClipboardItem.getType() now register shared, non-capturing host functions through performPromiseThenWithContext — the same reaction convention the streams code uses (handler(value, contextCell), context at argument(1)). Grep for JSNativeStdFunction, Strong<, or ->then( in JSClipboard.cpp / JSClipboardItem.cpp and all three are zero.

One strong, in the returned promise. The state the lambdas used to capture lives in a context cell whose fields are visited — JSClipboardWriteState for write(), an InternalFieldTuple{item, index} for getType() — so the C++ side holds none at all. The last one was structural: C++ had to chain its promise onto the one Rust created. Bun__Clipboard__writeBlobs now takes the promise write() already returned and the scheduled job settles it, leaving that job's JSPromiseStrong as the only strong handle involved.

One behaviour change worth surfacing: only an object can be a thenable, so string representations normalize inline now and cost no reaction — getType() on a string resolves a tick earlier than on a Blob representation of the same item. Nothing pins that ordering; happy to route strings through a reaction too if you'd rather they stay uniform.

On CI: the red windows-11-aarch64 - test-bun was test/js/sql/postgres-{binary-array-bounds,invalid-message-length} — this branch predated #33962, the flake fix for exactly those two files. Merged main in 82b761a and those are green now.

That shard is still red on a different one: bun-install-registry > hoisting > peers > it should hoist 1.0.1 when peer *. This branch touches no install/resolver/lockfile code and the same shard is failing concurrently on unrelated PRs, so I don't think it's this change — but it's Windows-only and verdaccio-backed, so I couldn't reproduce it locally to prove that. A retry of that one job would settle it.

Comment thread src/jsc/bindings/JSClipboardItem.cpp Resolved
@cirospaciari

Copy link
Copy Markdown
Member Author

@robobun adopt it

@cirospaciari

Copy link
Copy Markdown
Member Author

@robobun thanks, the before/after numbers for read_types are exactly what that change needed.

On the two open questions:

  • Message-only window for OpenClipboard: a follow-up PR after this merges, not here. The finding is real (NULL does not exclude another NULL opener, in any process), and a clipboard window is what browsers use. But it is new Win32 surface that nobody reviewing this PR has asked for, the last maintainer review asked for less cross-operation machinery, not more ("isn't that a concern for userland code?"), and this head is green and waiting on re-review. Please keep the branch and the two-process repro; it makes a good self-contained PR with its own test.
  • dib_as_bmp length check: not needed. It only matters for a bitmap that starts with the three mask values as pixels.

Please do not push to this branch for either.

One CI note for build 119414 on 45942c1: the only job that did not pass is Windows 11 aarch64 - test-bun shard 4 of 8, which was cancelled because its agent could not be created ([azure] Operation timed out after 600000ms). No test failed; the other seven arm64 shards and all eight x64 shards passed.

@robobun

robobun commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator

@cirospaciari understood. I push nothing to this branch, and I drop the dib_as_bmp length check.

The window change waits for the merge. It is on robobun/3735dc8f/clipboard-window (ed138e4, one commit on top of 45942c1), and I open the follow-up PR from it after this PR merges. The commit has its own test, which does not depend on timing: the test holds the clipboard open with no window, as a console program does, and calls writeText(). Without the change, writeText() resolves, the text is replaced, and the holder's CloseClipboard fails. With the change, writeText() rejects with NotAllowedError, the holder still owns the open clipboard, and its text is intact. On windows-x64 the test fails before and passes after, and the file has 62 pass and 0 fail.

The two-process repro

Start two copies at the same time, with the arguments a 6000 and b 6000. Each copy prints its counts.

// One of two processes that use the clipboard at the same time through the
// public API only. Every value a read returns must be one that a write produced.
const id = process.argv[2];
const until = performance.now() + Number(process.argv[3] ?? 3000);
const stats = { id, writes: 0, reads: 0, writeRejected: {}, readRejected: {}, emptyReads: 0, foreignReads: 0, tornItems: 0 };
const png = new Blob([Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DQDwAEgAF/pooBPQAAAABJRU5ErkJggg==", "base64")], { type: "image/png" });
let i = 0;
while (performance.now() < until) {
  const text = `p${id}-${i++}`;
  try {
    await navigator.clipboard.write([new ClipboardItem({ "text/plain": text, "text/html": `<b>${text}</b>`, "image/png": png })]);
    stats.writes++;
  } catch (e) {
    stats.writeRejected[e.message] = (stats.writeRejected[e.message] ?? 0) + 1;
  }
  try {
    const items = await navigator.clipboard.read();
    stats.reads++;
    if (items.length === 0) stats.emptyReads++;
    else {
      const types = [...items[0].types].sort().join(",");
      // Both processes always write all three types in one write().
      if (types !== "image/png,text/html,text/plain") stats.tornItems++;
      else {
        const plain = await (await items[0].getType("text/plain")).text();
        const html = await (await items[0].getType("text/html")).text();
        if (!/^p[ab]-\d+$/.test(plain)) stats.foreignReads++;
        else if (html !== `<b>${plain}</b>`) stats.tornItems++;
      }
    }
  } catch (e) {
    stats.readRejected[e.message] = (stats.readRejected[e.message] ?? 0) + 1;
  }
}
console.log(JSON.stringify(stats));

One conflict, in src/runtime/image/codecs.rs. This branch added
Encoded::as_slice (Windows only) above Encoded::from_owned. On main,
from_owned lost #[allow(dead_code)] for #[cfg(any(target_os = "macos",
windows))] (#44086). The result keeps as_slice and takes main's
attribute for from_owned.
@robobun

robobun commented Sep 28, 2026

Copy link
Copy Markdown
Collaborator

@cirospaciari this PR now conflicts with main. I did not push anything: you asked me not to push to this branch, so the merge is yours, or mine if you say so.

The conflict. There is one, in src/runtime/image/codecs.rs, inside impl Encoded. It comes from #44086 (a7c73fd).

  • This branch added as_slice (#[cfg(windows)]) above from_owned, and from_owned kept #[allow(dead_code)].
  • Main replaced that attribute on from_owned with #[cfg(any(target_os = "macos", windows))].
  • Resolution: keep as_slice, and take the attribute from main for from_owned.
impl Encoded {
    #[cfg(windows)]
    pub(crate) fn as_slice(&self) -> &[u8] {
        // SAFETY: `bytes` is a live allocation owned by `self` until `Drop`.
        unsafe { self.bytes.as_ref() }
    }

    #[cfg(any(target_os = "macos", windows))]
    pub(crate) fn from_owned(bytes: Vec<u8>) -> Encoded {

What I checked with that merge, locally (45942c1 merged with main at a4f1429):

  • The diff against main is the same as before the merge: 50 files, 6503 insertions, 204 deletions.
  • cargo check --workspace passes for x86_64-pc-windows-msvc, aarch64-apple-darwin, x86_64-unknown-freebsd, aarch64-linux-android and x86_64-unknown-linux-gnu. No warning names a file of this PR. That matters because Say the platform once: real #[cfg] in place of stubs, placeholders, let _ = x and allow(...) #44086 removed the allow(dead_code) escapes, so dead_code now sees all of bun_runtime.
  • test/internal/source-lints passes.
  • Linux debug build, test/js/web/clipboard/clipboard.test.ts: 49 pass, 0 fail. With CI=1 and BUN_JSC_validateExceptionChecks=1: 57 pass, 0 fail. My machine was under heavy load, so I ran the file with a 120 s test timeout.

I could not run Windows this time. The Windows lanes in CI are the check for the Win32 tests after the merge.

@cirospaciari

Copy link
Copy Markdown
Member Author

@robobun fix conflicts and rebase

@cirospaciari

Copy link
Copy Markdown
Member Author

@robobun adopt again this PR

@robobun

robobun commented Sep 28, 2026

Copy link
Copy Markdown
Collaborator

@cirospaciari done. The branch has no conflict with main now, and I drive this PR again.

  • d28ff32 merges main (a4f1429) into the branch. The one conflict, in src/runtime/image/codecs.rs, has the resolution from my last comment. The checks in that comment are for this exact tree.
  • I merged and did not rebase. The branch has 122 commits that are not on main, and 23 of them are merges of main. A rebase replays the other 99 without the conflict resolutions that those merges hold, and it needs a force push over the history that your checkout has. The PR is squash-merged, so the commit on main is the same in both cases. If you want a linear branch all the same, tell me and I do it.
  • Windows is the one platform I could not run for this merge. I read the Windows lanes of the CI run for d28ff32 when they finish.

The window change for OpenClipboard stays a follow-up PR after the merge, as you decided.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

String::fromUTF8ReplacingInvalidSequences aborts the process on two
kinds of clipboard text. Text of 2 GiB or more fails its length
assertion:

  ASSERTION FAILED: string.size() <= String::MaxLength

Text of 1 GiB or more that is not all ASCII fails earlier, in the
allocation of its conversion buffer, with no message.

The completion that every platform shares now checks the length first
and converts with Zig::convertUTF8ToString, which has no such buffer.
Text that does not fit rejects with a NotAllowedError and fires no
paste event. Text of 1 GiB or more that is not valid UTF-8 rejects in
the same way.
sync::Options::timeout (POSIX) gives the child its own process group and
a deadline. At the deadline the group gets SIGKILL. The wait ends when
the child exits, so a process that the child started cannot hold the
call open through a captured pipe.

sync::Options::linux_pdeathsig passes the existing spawn option through.

Both are off by default. A caller that sets neither makes the same
system calls as before.
The backend ran each helper under a /bin/sh watchdog that sent SIGTERM
after 10 seconds. A helper that ignores SIGTERM was never ended, and the
operation never settled.

The backend now finds the helper on the PATH of the script and runs it
with the time limit of the synchronous spawn. At the limit the helper
and its process group get SIGKILL. The helper of a read also gets
SIGKILL when the Bun process is gone, and a read starts no further
helper for a worker that is stopping. A write still tries its next
helper then.

The test hook moves from the BUN_INTERNAL_CLIPBOARD_HELPER_TIMEOUT
environment variable to bun:internal-for-testing.
@robobun

robobun commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

@cirospaciari I pushed three commits. They are for two defects in the Linux backend at d28ff32. One decision in them is yours, so I name it first.

Your decision: the watchdog

On 2026-09-15 you wrote that the watchdog stays because spawn_sync has no timeout. Commits 2 and 3 give spawn_sync a timeout and remove the watchdog. Two shapes end the hang of defect 2:

A: time limit in the spawn (pushed) B: kill -9 "$c" in the watchdog string
Size process.rs +85/-6, clipboard.rs +117/-69 1 line
Helper that ignores SIGTERM ended ended (I checked it on d28ff32)
PATH with the helpers and no sleep limit works limit never fires
Child of the helper holds a captured socket pair wait ends at the exit of the helper wait never ends
Processes for one helper run 1 4
Test hook bun:internal-for-testing env var that production code reads
Shared code a fourth wait mode in spawn_posix, opt-in, one user none

I took A because the review of 2026-09-15 named the missing timeout as the cause of the shell string, and because B leaves the other three rows as they are. The cost of A is in shared code. A caller that sets no time limit makes the same system calls as before: 180 of 180 traces are identical.

If you prefer B in this PR and A as a follow-up PR after the merge, say so. Then I replace commits 2 and 3 with the one line.

The defects

  1. readText() aborts the process on large text. Text of 1 GiB or more that is not all ASCII aborts with no message. Any text of 2 GiB or more aborts with ASSERTION FAILED: string.size() <= String::MaxLength. A stand-in xclip that prints 1 GiB reproduces it (exit 134).
  2. A helper that ignores SIGTERM is never ended. readText() and writeText() never settle, because the watchdog sends only SIGTERM.

The commits

  • 96de433301: readText() rejects text that does not fit a string. It stands alone. With only this commit the file passes (51 pass, 0 fail).
  • 8cfe6b5e8d: opt-in Options::timeout and Options::linux_pdeathsig in sync::spawn.
  • cb80b73428: the backend finds the helper on PATH and runs it directly. BUN_INTERNAL_CLIPBOARD_HELPER_TIMEOUT is gone.
  • e46e2a181b and 94e7f1f07c (added after the first review of the push): SyncStdio::Fd is documented and asserted as for stdin only, the waits in the helper tests have a bound, and the test with a real 1 GiB payload is removed because it is too slow for CI.

What changes for users with A

  • The helper leads its own process group. A signal to the process group of Bun no longer reaches the daemon that xclip or wl-copy leaves behind.
  • An empty PATH finds no helper. The shell searched the working directory.
  • A helper script with no #! line fails with "could not be started". spawn: retry via /bin/sh when exec returns ENOEXEC #31717 adds that retry to the spawn.
  • When Bun is killed, the kernel ends the helper of a read. The hung helper of a write stays, because the limit lives in the Bun process.
  • A read in a worker that is terminated starts no further helper. A write still tries its next helper.

Not in this push

  • JobContext::cancel, which the same review asked for. worker.terminate() still waits for the helper run in flight, up to 10 seconds.
  • The permission model, and navigator.clipboard = mock that now throws. Both are in the PR body under Downsides for a maintainer decision. I changed no code for them.

Verified

  • Linux x64 debug, bun bd test test/js/web/clipboard/clipboard.test.ts at 94e7f1f07c: 62 pass, 31 skip, 0 fail. At cb80b73428 with CI=1 and BUN_JSC_validateExceptionChecks=1: 71 pass, 23 skip, 0 fail. CI build 122957 passed on 94e7f1f07c.
  • Ten of the new test cases fail on d28ff32. Three more pass there and pin a clause of the new code. With the clause removed, each of them fails.
  • bun run rust:check-all for Windows x64, macOS arm64, FreeBSD x64 and Android arm64: ok. Source lints: 170 pass.
  • Not run by me: Windows and macOS. CI is the check for them.

The measurements and the limits that stay are in the Notes block of the PR body.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread test/js/web/clipboard/clipboard.test.ts Resolved
Comment thread test/js/web/clipboard/clipboard.test.ts Resolved
Comment thread src/spawn/process.rs Resolved
For stdout and stderr the wait takes the fd as its own capture fd: it
drains it and closes it. The only caller passes it for stdin.
The 1 GiB test held several GiB and needed its own timeout. The branch
that it covered is reachable only with a payload of that size.

A wait for a file that a stand-in writes now fails with a message when
the file never comes.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread src/runtime/webcore/clipboard.rs Resolved
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Resolved
The spawn changed to "." in the child. That fails with EACCES for a
user who is not root when the working directory has lost its search
permission, and the operation then rejected with "No clipboard helper
was found". The child now keeps the working directory that it
inherits, as every other caller of the synchronous spawn does.
Main removed setSubclassStructureIfNeeded (#45008). ClipboardItem and
ClipboardEvent now use structureForNewTarget, as the other DOM
constructors do.
A child that loads bun:internal-for-testing needs more than a second
for it in a debug build, and several such children at once need longer.
The tests for the time limit and for text that is too large now set
their limit in the test process and call the clipboard there. The one
test that needs other kernel features keeps its child.
@robobun

robobun commented Oct 14, 2026

Copy link
Copy Markdown
Collaborator

@cirospaciari the branch had a conflict with main again. It is merged and pushed. The head is c0eff53.

The merge (5dfb92d, main at 281698a)

  • One textual conflict, in src/spawn/process.rs. Main added SyncStdio::Pipe(Fd) (Add bun lint and bun format #44805) for the same purpose as SyncStdio::Fd(Fd) of this PR. I took the variant of main and removed ours. clipboard.rs now passes the payload as SyncStdio::Pipe, and the debug assertion from e46e2a1 names Pipe.
  • One conflict that git did not see. Main removed setSubclassStructureIfNeeded (DOM constructors: read new.target.prototype before X::create() #45008), so JSClipboardItem.cpp and JSClipboardEvent.cpp did not compile. They now call structureForNewTarget before create(), as the other DOM constructors on main do (92a8769). I checked by hand that class extends, Reflect.construct with a new target, and a prototype getter that throws behave as before.

Two more commits

  • f2a7869 answers the last review nit. The helper spawn no longer changes to "." in the child. For a user who is not root, that call fails with EACCES when the working directory has lost its search permission, and the operation then rejected with "No clipboard helper was found". This has no automated test: it needs a user who is not root, and the machine I test on runs as root. I checked it by hand as uid 65534.
  • c0eff53 changes tests only. The tests for the time limit and for text that is too large now set their hook in the test process and call the clipboard there. In a debug build a child needs about 1.5 seconds to load bun:internal-for-testing, and 10 children that load it at once need 4 to 7 seconds each. With that, the hung-helper tests reached the 5 second limit of a test on an idle machine. One test keeps its child, because it needs a process without pidfd and memfd.

Verified at c0eff53 (Linux x64, debug build)

  • bun bd test test/js/web/clipboard/clipboard.test.ts: 62 pass, 31 skip, 0 fail, in three runs.
  • The same file with CI=1, BUN_JSC_validateExceptionChecks=1 and --timeout 120000: 70 pass, 23 skip, 0 fail. With the default timeout one worker test timed out once in that mode.
  • bun run rust:check-all for Windows x64, macOS arm64 and FreeBSD x64: ok.
  • Source lints: 178 pass. bun-types: 22 pass. run-propagate-sigkill, test-changed and bun-pm-version: 48 pass.
  • Not run by me: Windows and macOS. CI is the check for them.

The decision from my earlier comment is still open: the time limit in the spawn (pushed), or kill -9 in the watchdog string with the spawn limit as a follow-up PR. It is in #33312 (comment).

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread src/sys/windows/mod.rs Resolved
bun_sys::windows::user32 has its own module with the clipboard externs
now, and nothing else names the placeholder.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment on lines +1158 to +1159
let answer = match types {
[mime] => read_one(helper, *mime, env, ticket)?,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): on Wayland, readText() can resolve with HTML, JSON or a file:// list when the selection owner offers no plain-text type, while read() on the same clipboard reports no text/plain. read_types sends a single-type request straight to read_one at clipboard.rs:1159, so wl-paste --no-newline --type text runs with no TARGETS gate, and wl-paste's text type falls back to any text-like MIME (text/html, text/uri-list, application/json, ...). Fix: apply the same offered-types gate read_offered uses (clipboard.rs:1144, offers(&targets, Mime::TextPlain)) to the single-type path, so readText() resolves "" unless a plain-text target is offered, matching read().

Why this was flagged

On Linux with WAYLAND_DISPLAY set, a script calls navigator.clipboard.readText() after the user copied something whose owner offers only text/html, text/uri-list or application/json. Op::ReadText at src/runtime/webcore/clipboard.rs:121 calls read_types(&[Mime::TextPlain], ..), which at clipboard.rs:1158-1159 matches the one-element slice and calls read_one directly, bypassing read_offered. read_one at clipboard.rs:1112 runs wl-paste --no-newline --type text from clipboard.rs:896-898; wl-paste's text type resolves to any MIME its mime_type_is_text heuristic accepts, so the helper exits 0 with the HTML or URI list and readText() resolves with it. navigator.clipboard.read() on the same selection goes through read_offered at clipboard.rs:1128-1152, where offers() at clipboard.rs:958-971 only accepts text/plain, UTF8_STRING, STRING or TEXT for Mime::TextPlain, so the returned item has no text/plain. The same script therefore gets text from readText() and none from read(); browsers return "" from readText() when no plain-text flavour is present. The base branch has no clipboard API.

Verification: nit — triggers on Linux with WAYLAND_DISPLAY set when the selection owner offers only text-like non-plain types and a script calls navigator.clipboard.readText(). read_types matches the one-element slice with [mime] => read_one(helper, *mime, env, ticket)? (clipboard.rs:1159), so the offers() TARGETS gate is skipped and wl-paste --no-newline --type text (line 897) returns any text flavour.

Comment on lines +287 to +306
inline CFRef pngFromImageData(const Syms* s, CFRef data)
{
CFRef source = s->CGImageSourceCreateWithData(data, nullptr);
if (!source) return nullptr;
CFRef image = s->CGImageSourceCreateImageAtIndex(source, 0, nullptr);
s->CFRelease(source);
if (!image) return nullptr;
CFRef png = nullptr;
CFRef type = s->CFStringCreateWithCString(nullptr, "public.png", kBunCFStringEncodingUTF8);
CFRef sink = s->CFDataCreateMutable(nullptr, 0);
CFRef destination = type && sink ? s->CGImageDestinationCreateWithData(sink, type, 1, nullptr) : nullptr;
if (destination) {
s->CGImageDestinationAddImage(destination, image, nullptr);
if (s->CGImageDestinationFinalize(destination))
png = std::exchange(sink, nullptr);
s->CFRelease(destination);
}
if (sink) s->CFRelease(sink);
if (type) s->CFRelease(type);
s->CGImageRelease(image);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (optional) On macOS, a script calling navigator.clipboard.read() while another local app left a huge or malformed TIFF on the pasteboard can exhaust memory or stall a pool thread for a long time. pngFromImageData (image_coregraphics_shim.cpp:287-306) decodes and PNG-encodes whatever public.tiff the pasteboard holds with no size check, unlike every other decode in the tree. Fix: bound the conversion the way bun_coregraphics_decode does at line 357, by comparing CGImageGetWidth*CGImageGetHeight against the caller-passed max_pixels (the Windows twin png_from_bmp already uses DEFAULT_MAX_PIXELS) and treating an oversize image as absent or as a failed read, and add a TIFF-only pasteboard test, which the suite currently lacks.

Why this was flagged

Trigger: on macOS the general pasteboard holds public.tiff but no public.png, and a script calls navigator.clipboard.read(); Op::Read runs platform::read_types(&Mime::ALL, ..) (src/runtime/webcore/clipboard.rs:122) on a pool thread. In bun_coregraphics_clipboard_read_types the png slot falls back to dataForType(s, pb, "public.tiff") (image_coregraphics_shim.cpp:659-661) and after the lock is released calls pngFromImageData(s, tiff) (:681). That helper does CGImageSourceCreateImageAtIndex then CGImageDestinationAddImage + CGImageDestinationFinalize (:289-301) with no dimension check, so ImageIO fully decodes and re-encodes an image of any declared size: a compressed TIFF declaring 65535x65535 asks for ~16 GiB of pixels. Every other decode in the tree is capped: bun_coregraphics_decode returns CG_TOO_MANY_PIXELS when w*h > max_pixels (:357), and the Windows sibling png_from_bmp calls codecs::decode(bmp, codecs::DEFAULT_MAX_PIXELS, ..) (clipboard.rs:628). test/js/web/clipboard/clipboard.test.ts has no TIFF case, so the conversion path is unexercised.

Verification: bun_coregraphics_clipboard_read_types (src/jsc/bindings/image_coregraphics_shim.cpp:659-661) falls back to dataForType(s, pb, "public.tiff") for the png slot, then at line 678-686 calls pngFromImageData(s, tiff) on the pool thread. pngFromImageData (lines 287-308) has no dimension or pixel-count check at all. By contrast bun_coregraphics_decode guards at line 354-357.

This branch was successfully deployed

1 active (outdated) deployment
staging - docs — 2368e166 Deployed Aug 12, 2026 by mintlify[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants